Codetopia 海港音樂祭的空氣,濕熱、黏稠,充滿著數萬人的汗水與期待。距離壓軸樂團登場只剩 30 分鐘,然而在應變中心的都卜勒雷達圖上,象徵暴雨的紫色區塊,正像一頭飢餓的猛獸,悄然無聲地張開了血盆大口。
控制室內一片死寂,只剩下伺服器風扇的低鳴。螢幕上的數據無情地刷新,風速、濕度、落雷機率……每一個數字都像一顆逼近的子彈。終於,一道簡潔卻沉重的指令劃破了凝重的氣氛:「執行雨備計畫,延後二十分鐘。」
決策下達了。然而,真正的風暴,才正要在人群中上演。
現場瞬間變成一場「各自表述」的災難片:
Roy|舞台轉場總管 的團隊,一群穿著黃色雨衣的身影在舞台上奔忙。有人在奮力鋪開沉重的防滑墊,另一組人卻在幾步之遙外,試圖解開主電源線路。(旁批:一場教科書等級的「競爭條件 Race Condition」,正在現實中上演,賭注是活生生的人命。)
Tara|餐車協調員 正對著無線電聲嘶力竭,她憑著一則剛收到的簡訊,指揮著 20 台餐車像貪食蛇一樣在人群中穿梭,試圖找到備援區。結果呢?官方廣播還在等正式稿,一大群聞著炸雞味而來的遊客,就這樣追著餐車跑進了死胡同。
交管隊的通訊頻道更是一片嘈雜,有人回報路已封鎖,有人卻說監控剛被關閉,資訊彼此矛盾,根本兜不攏。
大家都在做對的事,但執行的次序卻是一場即興演出。通知重複發送、動線互相卡死,事後要追查紀錄更是比登天還難。
就在所有人的神經都繃到極限時,一個冷靜、清晰,甚至可以說是有點「格格-入」的聲音,切入了混亂的通訊頻道。是新到任的 Elin|SOP 稽核官,一個多數人甚至還沒見過的臉孔。
她沒有咆哮,更沒有拍任何桌子。她只是問了一個讓整個應變中心瞬間鴉雀無聲的問題:
「請問,有誰能按順序,說出我們緊急預案的五個標準階段是什麼嗎?」
長達五秒的沉默,就是唯一的答案。
Elin 讓這片沉默在空氣中發酵了一會兒,才用一種銳利的語氣,繼續說道:「各位,問題不在於你們做錯了什麼。問題在於,你們正試圖『即興演奏』一首災難交響曲。而我們需要的,是『樂譜』。一份固定、不容置疑的總譜:永遠是 Check
,然後 Prepare
,接著 Switch
,再來 Notify
,最後 Audit
。至於你們各自演奏的樂器——那才是可以替換的『鉤子』。」
(是的,你沒看錯。Day 19 的調度中心和 Day 20 的狀態機都已就位,但今天,我們要用 Template Method 將這些珍珠串成一條不會斷的項鍊!)
今天的羅盤指向很明確:把「不變的流程」與「可變的細節」徹底分離。
GoF|Template Method:將演算法流程的骨架定義在基底類別,但將部分步驟的實作延遲到子類別中去覆寫(這些可覆寫的步驟,我們稱之為「鉤子」)。
EIP/EDA|Pipeline with Hooks:在訊息處理管線中,定義固定的處理階段(stage),並允許在各階段掛上可插拔的處理邏輯(hook),最終輸出統一格式的事件或命令。
MAS|SOP-Agent:讓所有代理(Agent)遵守一個共同的流程協定(例如:可以透過黃頁服務 DF 查詢「誰具備執行雨備 SOP 的能力」),而具體的執行細節則由代理內部的鉤子來處理。
讓我們把時間倒回 Elin 發問前的三分鐘,看看當時控制中心的程式碼長什麼樣子。那簡直是一場災難……
# 這段程式碼,人稱「默契驅動開發」
def run_rain_plan(area):
# 每隊都自己寫一版 SOP,順序全靠口頭約定...
if area.stage == "main":
power.off("main") # 有人習慣先斷電,夠安全吧?
mats.deploy("anti-slip")
api.broadcast("主舞台延後20分")
else:
mats.deploy("anti-slip") # 有人卻堅持先鋪墊,效率高啊!
power.off(area.stage)
if area.has_foodtrucks:
trucks.reroute("rain-zone") # 餐車先跑了
api.broadcast("餐車改到C區") # (旁批:又廣播一次,市民都精神分裂了)
# 交管隊更是各自為政,完全繞過了 Day 19/20 的協調中心和狀態機
if area.traffic_status != "paused":
api.close_road(area) # 直接呼叫底層 API,繞過管制
monitor.stop(area) # 有人執行完就閃了,根本忘了要停監控
audit.log("done") # (旁批:Done 是 done 了,但誰先誰後?根本無法審計!)
壞味道分析:這段程式碼的氣味,就像把濕襪子忘在健身房包包裡三天一樣濃烈。流程骨架完全外洩、步驟順序天馬行空、更致命的是,它與我們之前辛苦建立的 State/Mediator/Command 機制完全失聯,導致任何操作都無法回放、無法補償、無法審計。這正是 Template Method 要解決的核心痛點!
一句話概括:把像「啟動雨備」、「貴賓車隊進場」、「延後開演」這類執行階段固定、但細節各異的流程,抽象成一個 SOPTemplate.run()
的不可變骨架。至於每個場景的具體動作,就交給子類別去覆寫各自的「鉤子 (hook)」吧!
何時用 (When to Use):
✅ 當你有一個多步驟的演算法,且步驟的順序是穩定不變的。
✅ 流程中的大部分步驟都相同,只有少數幾個步驟需要客製化實作。
✅ 需要在流程的特定時點(如開始、切換、通知、回滾)建立一致的審計點或日誌。
何時不要用 (When NOT to Use):
⛔ 如果你只是想在單一步驟中切換不同的演算法,那用 Day 15 的 Strategy 模式 就夠了,殺雞焉用牛刀。
⛔ 如果流程的重點是多個物件之間複雜的互動協調,請讓 Day 19 的 Mediator 模式 擔此重任。
⛔ 如果這是一個需要跨越數小時甚至數日的長交易流程,那應該考慮更穩健的 Workflow Engine 或 Saga 模式。
與 Day 17/19/20 的協同作戰: Template Method 負責定義「劇本的起承轉合」。劇本中的具體「動作」,我們透過 Command 發出,確保可撤銷;跨部門的「溝通」,交給 Mediator 處理;而關鍵的「狀態轉移」,則由 State 機器來守門,徹底告別 if/else 叢林。
導播,鏡頭拉一下,給我們一張三層並置的全景圖!讓總設計師看看,這個 SOP 骨架在微觀、中觀、宏觀三個層次上是如何對齊的。
視角 | 觀念/模式 | 在城市的說法 |
---|---|---|
微觀(GoF) | Template Method (基底類定義骨架,子類覆寫鉤子) | SOPTemplate 類別與 RainSuspendSOP 等具體實作 |
中觀(EIP/EDA) | Pipeline with Hooks (固定階段+可插拔處理) | 事件處理管線:preCheck → prepare → switchOver → notify |
宏觀(MAS) | SOP-Agent (代理遵守共同流程協定,細節由內部鉤子處理) | 具備 RainSOP 能力的代理,遵循統一協定啟動雨備 |
微觀(GoF)|UML 類圖
中觀(EIP/EDA)|流程圖
這就是 Elin 提出的那份 SOP 藍圖,經過首席架構師的校準後,變得更加強韌。注意骨架是如何透過 try...finally
確保審計閉環,並將補償邏輯收歸中央。
from abc import ABC, abstractmethod
from typing import final
from functools import partial
class PreconditionError(Exception): pass
class NotificationError(Exception): pass
class SOPTemplate(ABC):
@final # 靜態型別檢查器會警告,但執行期仍可覆寫,需靠測試來保障
def run(self, ctx, area, run_id=None):
run_id = run_id or ctx.new_run_id()
ok = False
self._audit("SOP.Start", area, run_id)
try:
# 鉤子 1: 前置條件檢查
self.preCheck(ctx, area)
# 鉤子 2: 準備階段,僅暫存命令,尚未提交
self.prepare(ctx, area, run_id)
# 鉤子 3: 事務邊界,提交命令 + 狀態轉移
self.switchOver(ctx, area, run_id)
ok = True # 核心領域狀態變更完成,後續的通知失敗不應觸發回滾
# 通知是外部副作用,應獨立處理失敗
try:
self.notify(ctx, area, run_id)
except Exception as ne:
# 記錄通知失敗,並將其排入重試佇列,但不影響核心流程結果
self._audit("SOP.NotifyFail", area, run_id, error=str(ne))
# 以部分套用固定通知參數,確保重試時使用相同 ctx/area/run_id
ctx.notifier.retry(run_id, partial(self.notify, ctx, area, run_id))
except Exception as e:
# 任何核心流程的錯誤都會觸發中央補償機制
self._compensate(ctx, run_id)
self._audit("SOP.Error", area, run_id, error=str(e))
raise
finally:
# 無論成功或失敗,都會確保審計閉環
status = "OK" if ok else "ERROR"
self._audit("SOP.End", area, run_id, status=status)
# --- 鉤子 (Hooks): 子類必須實作的核心擴展點 ---
@abstractmethod
def preCheck(self, ctx, area): ...
@abstractmethod
def prepare(self, ctx, area, run_id=None): ...
@abstractmethod
def switchOver(self, ctx, area, run_id=None): ...
def notify(self, ctx, area, run_id=None):
# 預設為空操作,子類可選覆寫
pass
# --- 基礎設施 (Infrastructure): 不應被覆寫的內部方法 ---
@final
def _compensate(self, ctx, run_id):
# 以已成功命令堆疊的逆序,逐一執行對應的補償命令
print(f"Infra: Rolling back executed commands for run_id: {run_id}")
ctx.compensator.rollback(run_id)
@final
def _audit(self, evt, area, run_id, **extra):
# 審計日誌固定 schema:{evt, run_id, area, ...extra}
ctx.audit.log(evt, area, run_id, **extra)
class RainSuspendSOP(SOPTemplate):
def preCheck(self, ctx, area):
# 增加對 None 的防禦性處理,避免 TypeError
lvl = ctx.weather.rain_level(area)
if lvl is None or lvl < 2:
raise PreconditionError(f"Rain level not critical or unavailable: {lvl}")
def prepare(self, ctx, area, run_id=None):
# 將命令「暫存」到上下文中,等待 switchOver 階段提交
ctx.stage_command("DeployMats", area, idempotency_key=run_id)
ctx.stage_command("PowerOffStage", area, idempotency_key=run_id)
def switchOver(self, ctx, area, run_id=None):
# 設計註解:此處 commit 與 state handle 需藉由 Outbox Pattern 或
# 同一資料庫交易來確保「觀察到的一致性」。
ctx.commit_staged_commands(run_id)
# 帶上 expected_ver 以處理併發競爭 (與 Day 20 的狀態機設計合拍)
# 若版本不符則拋出 StaleStateError,由上層決定重試或丟棄
ctx.state.handle("rain_alert", area=area, expected_ver=ctx.state.version_of(area))
def notify(self, ctx, area, run_id=None):
# Mediator 端應以 run_id 做去重,避免因重試導致重複廣播
ctx.mediator.broadcast(f"雨備啟動,{area} 區域活動延後20分鐘", correlation_id=run_id)
看到了嗎?Template Method 就像一個稱職的專案經理,它只負責定義「該做什麼」以及「按什麼順序做」,而把「具體怎麼做」的權力下放給最專業的團隊。
即使是好的模式,也可能被誤用。在 Codetopia,我們對這些「壞味道」特別敏感:
🚩 骨架不骨架:如果基底類別的 run()
方法沒有被宣告為 @final
且缺乏測試契約保護,讓子類可以輕易地改變步驟順序或提早 return
,那這個骨架就形同虛設了。
🚩 鉤子滿天飛:把演算法的每一步都做成鉤子,以為這樣最靈活。結果是,你根本沒有一個共同的流程,只是換個方式寫了一堆義大利麵。鉤子應該是「少數但關鍵」的擴展點。
🚩 違反里氏替換原則 (LSP):子類在覆寫鉤子時,改變了前置條件或拋出了基類未預期的錯誤,導致上層呼叫者無法在不改變程式碼的情況下安全地替換子類。
🚩 把 Mediator/State/Command 的職責寫進子類:讓 RainSuspendSOP
自己去處理複雜的跨部門協調、狀態管理、命令派發,這等於是又回到了那個緊密耦合的地獄。(正確做法:應該是透過注入的 ctx
或 service
物件來呼叫這些外部服務)。
將視角拉高,Template Method 不僅僅是幾個類別的合作。在更宏觀的架構中,它化身為:
EIP Pipeline:一個以固定 stage(例如:驗證 → 預備 → 切換 → 通知 → 審計)為骨架的訊息處理管線。每個 stage 的具體實現,可以對應到不同局處的 Adapter,而所有副作用都以標準化的命令訊息發出(最好還帶上 idempotencyKey
/ run_id
防止重複執行)。
MAS 協定:在多代理系統中,黃頁服務 (DF) 會公開哪些代理具備 supports: RainSOP | VIPConvoySOP
的能力。協調者代理會按照 SOP 骨架的順序發布意圖(如 request-prepare
),並等待接收到「階段完成」的事件回報,一旦失敗,則派發補償命令。
現在,讓我們回到那個混亂的音樂祭現場。
Given 雷達偵測到雨勢已達雨備門檻,
When Elin 冷靜地對主舞台區域執行 RainSuspendSOP.run(),
Then:
preCheck
鉤子檢查通過。
prepare
鉤子將鋪設防滑墊和斷電的命令暫存。
switchOver
鉤子原子性地提交命令,並將系統狀態安全地轉移至 rain_alert
。
notify
鉤子被觸發;即使通知服務暫時失敗,核心流程狀態也不會被回滾,只會記錄錯誤並排入重試。
無論成功或失敗,finally
區塊都會確保執行 SOP.End
審計,達成日誌閉環。
若中間任何一步失敗,中央補償機制 _compensate
會被自動調用,依序回滾已執行的命令。
若使用同一個 run_id
重複執行,系統應具備冪等性,不會產生重複的副作用。
這套流程,完美地將 Day 20 的 entry/exit 呼叫收斂為一個可複用、更強韌的骨架。混亂,終結於此。
要確保這套 SOP 萬無一失,品保團隊提出了以下測試策略:
骨架序列契約測試:對 SOPTemplate.run()
進行測試,使用 Mock 或 Spy 物件來打桩 (stub),並斷言 preCheck
→ prepare
→ switchOver
→ notify
的呼叫順序是固定不變的。
鉤子覆寫行為測試:針對 RainSuspendSOP
這樣的具體子類,驗證它的 switchOver
鉤子是否確實呼叫了 State
物件的 handle
方法;並驗證在模擬失敗時,中央補償器 _compensate
會被調用。
整合回放測試:建立一個基於固定時間基準和假事件匯流排 (Fake Event Bus) 的整合測試,重放一次從「正常 → 失敗 → 補償 → 恢復」的完整日誌,確保整個流程的可審計性。
冪等性測試:使用同一個 run_id
重複呼叫 run()
方法,驗證系統不會產生第二份副作用(例如:不會重複發送命令)。
補償順序測試:刻意在流程的第 N 個步驟製造失敗,驗證補償機制是否按照已成功步驟的逆序來執行回滾。
總設計師,換你出手了!身為 Codetopia 的大腦,下面這兩個挑戰你選哪個?
實作題:除了雨備,貴賓車隊的到來也需要一套嚴謹的 SOP。請為 VIPConvoySOP
設計它需要覆寫的鉤子:
preCheck
鉤子需要確認貴賓動線已完全清空,若否則拋出 PreconditionError
。
prepare
鉤子需要暫存預留交管資源的命令。
switchOver
鉤子需要提交命令,並將交通狀態切換到一個 Active → EmergencyPaused → Resume
的受控時間窗口(記得帶上 expected_ver
)。
二選一設計題:假設氣象系統不穩定,在 10 秒內反覆發出「下雨—停了—又下雨」的觸發信號,導致雨備 SOP 不斷被「啟動-停止-啟動」。你會:
A. 堅持 Template 骨架,在 RainSuspendSOP
的 preCheck
鉤子裡增加一個抑制器(debounce)條件來過濾掉抖動信號。
B. 保持 Template 不變,把去抖動的邏輯下放到更底層的 State 或 Mediator 層去處理。
你選 A 還是 B?為什麼?(提示:思考一下不變的骨架與可變的觸發條件應該在哪個層次被分離。您的技術建議已暗示 B 是更穩妥的實務解!)
今日核心:把「大家各做各的對」,昇華為「一起照著同一套流程做對」。
今天我們用 SOP 骨架馴服了流程的混亂,但如果我們需要在不修改 SOP 本身結構的前提下,為它加上各種花式的審計報表(例如:計算耗時、導出 PDF、產生 JSON…)該怎麼辦呢?
明日預告:Day 22|Visitor(外掛行為)—— 在不修改資料結構的前提下,替你的 SOP 審計報表,加上百變的計算與導出能力!
為了確保在不支援 Mermaid 渲染的環境中也能正常閱讀,以下提供文中圖表的 ASCII 替代版本:
┌─────────────────────────────────────┐
│ SOPTemplate │
│ <<abstract>> │
├─────────────────────────────────────┤
│ + run(ctx, area, run_id) 【final】 │
│ # preCheck(ctx, area) 【abstract】│
│ # prepare(ctx, area) 【abstract】│
│ # switchOver(ctx, area) 【abstract】│
│ # notify(ctx, area) │
│ # _compensate(ctx, run_id) 【final】│
│ # _audit(evt, area, ...) 【final】│
└─────────────────────────────────────┘
▲
│
┌─────────┼─────────┐
│ │ │
┌───────▼──────┐ ┌▼──────┐ ┌▼──────────┐
│RainSuspendSOP│ │VIPSOP │ │DelayStart │
│ │ │ │ │ SOP │
├──────────────┤ ├───────┤ ├───────────┤
│+preCheck() │ │+... │ │+... │
│+prepare() │ │ │ │ │
│+switchOver() │ │ │ │ │
│+notify() │ │ │ │ │
└──────────────┘ └───────┘ └───────────┘
繼承關係說明:
- SOPTemplate 定義不可變的骨架流程
- 子類別覆寫 abstract 鉤子方法
- final 方法保護核心流程不被修改
開始 SOP 執行
│
┌────▼────┐
│preCheck │ ◄─── 鉤子1:前置條件驗證
│(Hook 1) │
└────┬────┘
│ 通過
┌────▼────┐
│ prepare │ ◄─── 鉤子2:準備階段(暫存命令)
│(Hook 2) │
└────┬────┘
│
┌────▼─────┐
│switchOver│ ◄─── 鉤子3:核心切換(提交+狀態轉移)
│(Hook 3) │
└────┬─────┘
│ 成功
┌────▼────┐
│ notify │ ◄─── 鉤子4:通知(允許失敗,不回滾)
│(Hook 4) │
└────┬────┘
│
┌────▼────┐
│結束審計 │ ◄─── 固定:記錄 SOP.End
└─────────┘
異常處理路徑:
任何步驟失敗
│
┌────▼─────────┐
│ _compensate │ ◄─── 固定:中央補償機制
│ (回滾) │
└────┬─────────┘
│
┌────▼────┐
│錯誤審計 │ ◄─── 固定:記錄 SOP.Error
│ 結束 │
└─────────┘
┌──────────────────────────────────────────────────────────┐
│ 宏觀(MAS)層 │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │SOP-Agent-A │ │SOP-Agent-B │ │Coordinator │ │
│ │支援RainSOP │◄──►│支援VIP SOP │◄──►│ Agent │ │
│ │ │ │ │ │ │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
└──────────────────────┬───────────────────────────────────┘
│ 協定遵循
┌──────────────────────▼───────────────────────────────────┐
│ 中觀(EIP/EDA)層 │
│ Pipeline: preCheck → prepare → switchOver → notify │
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │Stage│───►│Stage│───►│Stage│───►│Stage│───►│Audit│ │
│ │ 1 │ │ 2 │ │ 3 │ │ 4 │ │ End │ │
│ └─────┘ └─────┘ └─────┘ └─────┘ └─────┘ │
│ ▲ ▲ ▲ ▲ │
│ └──────────┼──────────┼──────────┘ │
│ 可插拔Hook處理邏輯 │
└──────────────────────┬───────────────────────────────────┘
│ 實作細節
┌──────────────────────▼───────────────────────────────────┐
│ 微觀(GoF)層 │
│ SOPTemplate │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ final run() { │ │
│ │ try { │ │
│ │ this.preCheck() // Hook 1 │ │
│ │ this.prepare() // Hook 2 │ │
│ │ this.switchOver() // Hook 3 │ │
│ │ this.notify() // Hook 4 │ │
│ │ } catch { │ │
│ │ this._compensate() // 中央補償 │ │
│ │ } finally { │ │
│ │ this._audit() // 審計閉環 │ │
│ │ } │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
音樂祭現場:暴雨警報!
時序圖:
Elin ──┐
│ 1. 啟動 RainSuspendSOP.run()
▼
SOPTemplate ──┐
│ 2. 呼叫 preCheck()
▼
Weather API ──┐
│ 3. 回傳雨量等級 = 3 (通過)
▼
SOPTemplate ──┐
│ 4. 呼叫 prepare()
▼
Command Store ┐
│ 5. 暫存: DeployMats, PowerOffStage
▼
SOPTemplate ──┐
│ 6. 呼叫 switchOver()
▼
State Machine ┐
│ 7. 狀態:normal → rain_alert ✓
│ 命令:提交到執行佇列 ✓
▼
SOPTemplate ──┐
│ 8. 呼叫 notify()
▼
Mediator ─────┐
│ 9. 廣播: "雨備啟動,主舞台延後20分"
▼
Audit Log ────┐
│ 10. 記錄: SOP.End, status=OK
└── 🎯 任務完成!
若第7步失敗:
├─ _compensate() 回滾已暫存的命令
├─ _audit() 記錄 SOP.Error
└─ 拋出異常給上層處理